Published on

[Spring] Spring Transaction 에 대해 PART 1 - 개념, 특징

Authors
  • avatar
    Name
    Woojin Son
    Twitter

Intro

오랜만에 다시 돌아왔습니다. 상반기 동안 큰 프로젝트를 하나 진행중인데, 마무리 되는대로 밀렸던 글들을 몰아서 쓸까 합니다.  그새 더 쌓인 인사이트를 공유 할 생각에 신나네요.

이번 포스팅에서는 Spring Transaction 에 대해 쌓인 인사이트를 정리해서 전달 드릴까 합니다.

이번 시리즈는 세가지로 나뉩니다. 첫 번째 파트에서는 기본 개념과 특징, 두번 째 파트에서는 세부적인 설정 법들에 대해 포스팅하겠습니다.

그럼 첫 번째 포스팅인 Spring Transaction 의 개념과 특징에 대해 알아보도록 하겠습니다.

Transaction

데이터베이스에서 Transaction 이 존재하듯, Spring 에서도 로직 플로우에 대한 Transaction 이 존재합니다.

여기서 Transaction 이란, 로직을 이루는 하나의 단위라고 이해 할 수 있습니다. 실제 사전적인 정의는 하나 이상의 SQL 문장들로 이루어진 논리적인 작업단위 를 뜻합니다. Spring 에서 REST API 하나를 호출했을 때 최종적으로 ResponseBody에 결과값이 전달 되기까지의 모든 과정들에 여러개의 SQL 문이 참여할 수 있겠죠. 스프링 애플리케이션은 이러한 하나의 작업단위에 대한 컨트롤을 지원합니다.

@Transactional
@Service
class TestService (
	private val testRepository : TestRepository,
	private val testLogRepository : TestLogRepository
) {
	fun testLogic() : TestEntity {
		val result = testRepository.findById(id = 123L)
		result.updateInfo(param = "asdf")
		val savedResult = testRepository.save(result)
		testLogic(savedResult)
		return savedResult
	}

	fun testLogic2(param : TestEntity) {
		val logData = TestLog.from(param)
		testLogRepository.save(logData)
	}
}

만약 위의 로직이 모종의 이유로 실패했다고 가정 해 보겠습니다. 두 개의 테이블에 데이터가 저장 되고있는 케이스입니다. TestRepository 에는 데이터가 저장되었는 데, TestLogRepository 에 데이터가 저장되지 않았거나, TestLog의 정적 팩토리 메소드에서 문제를 일으키거나 등의 케이스에서 이 연계 로직은 실패할 수 있습니다.

만약 따로 @Transactional 어노테이션을 사용하지 않는다면, 연계 로직 간에 데이터의 부분적인 변경이 생길 수 있습니다. testLogic 은 성공했는 데 testLogic2 가 실패했다면, 데이터는 성공 한 쪽만 반영될 수 있다는 것이죠.
예시에서는 로그 테이블에 데이터를 적재하는 케이스를 예시로 들었는데, 가벼워 보일 수 있겠지만 추후 로그 유실로 인해 생기는 문제를 대응 할 수 없게 됩니다.

이를 방지하기 위해 @Transactional 어노테이션을 사용한다면, 두 가지 로직 중 하나라도 실패 한 경우 편리하게 핸들링 할 수 있습니다. 특별한 옵션을 주지 않는다면 testLogic2 가 만약 실패했다면 롤백 처리가 되고 데이터는 반영되지 않습니다.

Transaction 의 특징

잠시 시간을 내서 Transaction 의 특징에 대해 짚고 넘어가겠습니다. Transaction 은 크게 네 가지 특징을 가집니다.

  • 원자성 (Atomicity)
    • 한 트랜잭션 내의 실행 작업은 하나의 단위로 처리합니다.
  • 일관성 (Consistency)
    • 트랜잭션은 일관성 있는 데이터베이스 상태를 유지합니다.
  • 독립성 (Isolation)
    • 동시에 실행되는 트랜잭션들이 서로 영향을 미치지 않아야 합니다.
  • 지속성 (Durability)
    • 트랜잭션을 마무리하면 항상 결과를 저장시켜야 합니다.

Spring 의 Transaction 기능을 사용하지 않는다면 이런 네 가지 특징들을 보장받을 수 없습니다. 아마 직접 구현하지 않는 한, 이미 예시에서 말씀 드렸듯이 두 가지 데이터를 업데이트 하는 과정에서 이미 단위가 분리 된 시점에서 로직의 원자성을 보장할 수 없게 되었습니다.

Spring Transaction 의 특징

아래와 같이 하나의 로직 단위가 실패한 경우 데이터를 롤백해야 하는 케이스인 경우 @Transactional 어노테이션이 도움을 줄 수 있습니다.

fun testLogic(param : Long) {
	val connection = dataSource.connection
	connection.autoCommit = false

	try {
		//Do Something
		connection.commit()
	} catch (e : SQLException) {
		connection.rollback()
	}
}

이렇게 직접 매 로직마다 실패 시의 에러 처리를 하는 것도 방법이겠지만 이렇게 처리하는 경우 매번 로직을 작성 할 때마다 Try / catch 에 의존해야 합니다. 또한 종류가 다른 Transaction 을 연속적으로 처리하는 경우엔 어떻게 해야 할까요?

일일히 매번 이렇게 코드를 작성하면 코드 작성에 대한 공수도 많이 들 뿐더러 가독성 또한 좋지 않습니다.

Spring 으로 백엔드 애플리케이션을 개발하고 있는 이상 우리는 Spring 을 최대한 활용해야 겠죠. Spring Transaction 은 크게 세 가지 기술을 제공하고 있습니다.

Transaction 동기화

보통 트랜잭션을 이루는 SQL 문들은 하나가 아닌 경우가 많습니다. 하나의 작업 단위기 때문에 여러개의 SQL 에 맞물릴 수 밖에 없습니다.

또한 하나의 DAO 만 사용한다는 보장 또한 없습니다. 위의 예시만 봐도 두가지 DAO (TestRepository, TestLogRepository) 를 사용하고 있습니다.

이런 경우 두 DAO 는 하나의 Connection 을 사용해야 합니다. Spring 에서는 트랜잭션을 시작하기 위해 생성한 Connection 객체를 별도의 공간에 보관하고, 커넥션이 필요한 DAO 에서 사용하는 형태로 Transaction 동기화를 구현했습니다.

만약 이런 기능을 사용하지 않는다면 DAO 마다 파라미터를 통해 Connection 객체를 받아야 했을 것입니다.

class TestDao {
	fun save(connection : Connection, entity : TestEntity) {
		try {
			val sql = "INSERT INTO TEST(seq, name, age) VALUES(?, ?, ?)"
			val preparedStatement = connection.prepareStatement(sql)
			preparedStatement.setString(1, entity.seq)
			preparedStatement.setString(2, entity.name)
			preparedStatement.setString(3, entity.age)
			preparedStatement.execute()
		} catch(e : SQLException) {
			e.printStackTrace()
		}
	}
}

하나의 Transaction 을 보장받기 위해서는 DAO 들의 로직들이 모두 하나의 Connection 에서 이루어 져야 합니다. 이대로면 DAO 들은 모두 파라미터로 Connection 을 전달 받아야지만 로직을 처리할 수 있습니다.

그래서 Spring 에서는 TransactionSynchronizationManager 에서 Transaction 을 관리하고, DataSourceUtils 에서 Connection 들에 접근할 수 있습니다. 이러한 Connection 객체들은 작업 쓰레드 마다 독립적으로 관리됩니다.

Transaction 추상화

JDBC, Hibernate 등 Spring 에서 애플리케이션 내의 DAO 를 개발하는 데 사용되는 여러 기술들이 있지만, 이런 모든 기술들마다 종속적인 코드를 작성하게 된다면 유지보수 측면에서 많은 이슈를 겪게 될 것입니다.

Spring 에서는 PlatformTransactionManager 를 통해 특정 기술에 종속되지 않고도 Transaction 을 처리할 수 있도록 지원하고 있습니다.

AOP 를 통한 Transaction 분리

fun testLogic(param : List<TestEntity>) {
	val status = this.transactionManager.getTransaction(DefaultTransactionDefinition())
	try {
		for(entity in param) {
			//Do Something
			entity.doSomeLogic()
			entity.doSomeLogic2()
			testLogService.saveLog(testEntity)
		}

	} catch(Exception e) {
		e.printStackTrace()
		this.transactionManager.rollback(status)
		throw e
	}

}

코드 상에서 Transaction 코드와 로직 코드가 복잡하게 얽혀 있는 경우엔 메소드의 책임이 복잡해질 수 있습니다.

Spring 에서는 Aspect Oriented Programming 을 지원합니다. 특정한 기능들을 클래스 밖의 별도의 모듈로 작성시킬 수 있는 기능이고, @Transactional 어노테이션이 그 예시입니다.

여기서 잠시 Spring 의 AOP 에 대해 이야기 하고 넘어가자면, 앞서 말씀드렸듯 특정한 기능들을 클래스 밖의 별도 모듈로 작성 시키게 된다면, 공통적으로 사용하게 되는 기능들에 대해 일관성 있는 코드를 작성할 수 있습니다.

예를 들면 로깅, 이번 예시에서 나오는 Transaction 등 여러 클래스에서 공통적으로 사용하는 기능들이 존재합니다. 이런 기능을 사용할때마다 매번 Logger 인스턴스를 생성하거나 Transaction 을 불러올 수도 있겠지만, 흔히 이야기하는 Boilerplate 코드들이 남발하게 됩니다.

이러한 반복적이고 여러곳에서 쓰이는 기능들을 흩어진 관심사 (Cross Cutting Concerns) 라고 합니다. AOP 는 이러한 흩어진 관심사들을 하나로 모아주는 역할을 합니다. 코드의 반복을 줄여주며, 코드가 변경 된 경우 수정의 여지를 줄여줄 수 있습니다.

Spring Transaction 의 동작 원리

Spring AOP 는 Proxy 패턴을 사용합니다. Proxy 패턴이란, Proxy 즉 간접적으로 다른 인스턴스를 참조하는 인스턴스를 사용하는 방식입니다. Spring 애플리케이션에서 작성 핸 AOP 클래스들은 Bean 으로써 등록되고, Proxy 를 통해 실제 Target Method 에게 간접적으로 요청을 보내는 구조로 작동합니다.

Proxy 를 쓰지 않고 직접 호출하게 할 수도 있겠습니다만, 그렇게 된다면 Aspect 에 정의 된 기능을 사용하기 위해 우리가 원하는 시점에 Aspect 인스턴스를 직접 호출해야 합니다. Spring 에서는 Target 클래스 또는 상위 인터페이스를 상속하는 프록시 클래스를 생성하고, 프록시 클래스에서 기능과 관련 된 처리를 합니다. Target 에서는 Aspect 에 대해 알 필요 없이 비즈니스 로직에만 집중할 수 있습니다.

Transactional 역시 마찬가지로 Proxy 형태로 동작합니다. Target Method 에 대한 요청이 들어왔을 때 AOP Proxy 가 가로챈 후, Transaction Adviser 에게 commit 또는 rollback 처리를 맡깁니다.

Outro

지금까지 Spring Transaction 기능에 대해 정리 해 보았습니다. 다음 포스팅에서는 Spring Transaction 의 세부적인 설정 값들에 대해 알아보겠습니다.